Conversation
Walkthrough관리자용 동아리 생성 기능을 추가합니다. 새로운 API 엔드포인트와 DTO, 어플리케이션 포트/서비스, 영속성·도메인 변경, 이벤트 기반 이미지 업로드 흐름 및 관련 테스트·DB 마이그레이션이 포함됩니다. Changes
Sequence Diagram(s)sequenceDiagram
actor Admin as 관리자
participant API as AdminCommandApiV2
participant UseCase as ClubCreateAdminUseCase
participant Service as ClubCommandService
participant CommandPort as ClubCommandPort
participant EventPort as ClubEventPort
participant DB as Database
participant EventListener as StorageEventListener
participant StorageService as StorageUploadUseCase
participant Storage as CloudStorage
Admin->>API: POST /api/v2/admin/clubs (JSON + icon/poster 파일)
API->>UseCase: createClub(AdminClubCreateCommand)
UseCase->>Service: createClub(command)
rect rgba(100,200,150,0.5)
Service->>CommandPort: existsByNameAndDivision(name, division)
end
rect rgba(150,150,200,0.5)
Service->>CommandPort: save(Club)
CommandPort->>DB: INSERT club
DB-->>CommandPort: clubId
end
rect rgba(200,150,150,0.5)
Service->>CommandPort: saveAll(List<ClubSns>)
Service->>EventPort: publishClubCreate(clubId, icon, poster, paths)
end
API-->>Admin: 201 ADMIN_CLUB_CREATE_SUCCESS
Note over EventListener: 트랜잭션 커밋 후 이벤트 수신
EventListener->>StorageService: uploadAll(UploadFileCommand)
StorageService->>Storage: 파일 업로드
Storage-->>StorageService: 업로드 결과
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 11
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
src/test/java/com/kustacks/kuring/club/domain/ClubCategoryTest.java (2)
19-25:⚠️ Potential issue | 🟡 Minor
ClubDivisionTest와 동일한 네이밍 불일치 문제가 있습니다.메서드 이름은
fromKorNameName이지만 실제로는ClubCategory.fromName(name)을 테스트하고 있으며, 테스트 데이터도 영문 값(academic,culture_art등)을 사용합니다. 메서드 이름을 실제 테스트 대상과 일치시켜 주세요.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/test/java/com/kustacks/kuring/club/domain/ClubCategoryTest.java` around lines 19 - 25, Rename the misnamed test method fromKorNameName to reflect what's being tested: e.g., fromName (or fromEnglishName); update the test method name that calls ClubCategory.fromName(name) and asserts equality with the expected ClubCategory so the method name matches the tested function ClubCategory.fromName and the English test data (academic, culture_art, etc.).
29-39:⚠️ Potential issue | 🟡 Minor예외 테스트도 동일한 네이밍 불일치가 있습니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/test/java/com/kustacks/kuring/club/domain/ClubCategoryTest.java` around lines 29 - 39, The test method name has a typo "fromKorNameNameException" causing a naming mismatch; rename the test method to a clear, consistent name (e.g., "fromKorNameException" or "fromNameException") so it matches the other tests, and keep the test body unchanged — locate the method in ClubCategoryTest and update the method declaration name that wraps the ThrowingCallable calling ClubCategory.fromName(name).src/test/java/com/kustacks/kuring/club/domain/ClubDivisionTest.java (2)
19-25:⚠️ Potential issue | 🟡 Minor테스트 메서드 이름과 실제 테스트 대상이 불일치합니다.
메서드 이름은
fromKorNameName이지만 실제로는ClubDivision.fromName(name)을 테스트하고 있습니다. 만약fromKorName메서드를 테스트하려는 의도였다면 테스트 본문과 테스트 데이터도 함께 수정해야 합니다. 기존fromName테스트를 유지하려는 의도라면 메서드 이름을 원래대로 유지하거나, 적절한 이름(예:fromNameTest)으로 변경하는 것이 좋겠습니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/test/java/com/kustacks/kuring/club/domain/ClubDivisionTest.java` around lines 19 - 25, The test method name fromKorNameName does not match the exercised method; update the test to match intent: either rename the test method fromKorNameName to a descriptive name like fromNameTest (or fromName_whenValidName_returnsDivision) to reflect that it calls ClubDivision.fromName(name), or change the test body to call ClubDivision.fromKorName(name) and adjust test data accordingly; locate the method in class ClubDivisionTest and update the method name or the invoked static method (ClubDivision.fromName vs ClubDivision.fromKorName) so name and behavior are consistent.
29-39:⚠️ Potential issue | 🟡 Minor동일한 네이밍 불일치 문제가 있습니다.
위의
fromKorNameName과 동일하게, 메서드 이름은fromKorNameNameException이지만 실제로는ClubDivision.fromName(name)의 예외 케이스를 테스트합니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/test/java/com/kustacks/kuring/club/domain/ClubDivisionTest.java` around lines 29 - 39, Test method name fromKorNameNameException mismatches the behavior: it calls ClubDivision.fromName(name) but the name implies a "Kor" variant; rename the test to reflect what's being tested (e.g., fromNameException or fromNameThrowsNotFoundException) or change the call to ClubDivision.fromKorName(name) to match the existing name; update the test method name (fromKorNameNameException) or the invoked method (ClubDivision.fromName(name)) so the test name and tested symbol align.
🧹 Nitpick comments (6)
src/main/java/com/kustacks/kuring/storage/application/StorageCommandService.java (1)
29-43: 업로드 실패 시 부분 실패 추적 고려.PR 목표에 따라 업로드 실패 시 로그만 남기고 DB는 유지하는 것이 의도된 설계입니다. 그러나 현재 구현에서는 어떤 파일이 성공/실패했는지 호출자가 알 수 없습니다.
향후 운영 모니터링 또는 재시도 로직을 위해 실패 건수나 실패한 파일 목록을 반환하거나 메트릭으로 노출하는 것을 고려해 볼 수 있습니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/com/kustacks/kuring/storage/application/StorageCommandService.java` around lines 29 - 43, The upload method in StorageCommandService currently swallows failures and only logs them, so callers cannot know which files succeeded or failed; change upload(UploadFileCommand.UploadFile file) to return a result object (e.g., UploadResult with fields success:boolean, key:String, error:String) or at minimum a boolean, populate it from the try/catch around storagePort.upload, and propagate that result up to the caller so callers can track successful vs failed files; alternatively (or additionally) emit a metric or increment a failure counter (e.g., via MeterRegistry) inside the catch blocks including file.key() and e.getMessage() to expose failures to monitoring.src/main/java/com/kustacks/kuring/storage/adapter/in/event/StorageEventListener.java (1)
11-11: 사용되지 않는@Slf4j어노테이션.
@Slf4j어노테이션이 있지만 클래스 내에서 로깅이 사용되지 않습니다. 이벤트 수신 로깅을 추가하거나 어노테이션을 제거해 주세요.💡 이벤트 수신 로깅 추가 예시
`@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT) // 트랜잭션 커밋 후 진행. public void uploadClubImages(ClubCreateEvent event) { + log.info("동아리 생성 이벤트 수신, 이미지 업로드 시작"); storageUploadUseCase.uploadAll(event.toCommand()); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/com/kustacks/kuring/storage/adapter/in/event/StorageEventListener.java` at line 11, 클래스에 붙은 사용되지 않는 `@Slf4j` 어노테이션을 정리하세요: StorageEventListener 클래스에서 로깅을 전혀 사용하지 않으면 `@Slf4j` 주석을 제거하고 불필요한 import를 삭제하거나, 이벤트 수신을 기록하려면 onApplicationEvent(또는 해당 이벤트 핸들러 메서드) 안에 수신 시점과 주요 페이로드(예: 이벤트 타입, id 등)를 기록하는 log.debug/info 호출을 추가해 주세요; 결정한 방식에 따라 `@Slf4j` 유지 시 log를 실제로 사용하거나, 사용하지 않으면 어노테이션과 lombok.slf4j.Slf4j import를 제거하십시오.src/main/java/com/kustacks/kuring/storage/application/port/in/dto/UploadFileCommand.java (1)
9-14: 불완전한 주석 제거 필요.Line 11의 주석
// 아래 내용들을.이 불완전합니다. 의미 있는 설명으로 수정하거나 제거해 주세요.또한,
InputStream을 record에 저장하는 것은 리소스 관리에 주의가 필요합니다. 현재StorageCommandService.upload()에서 try-with-resources로 적절히 처리하고 있으나, 이 DTO를 사용하는 다른 곳에서도 스트림 종료를 보장해야 합니다.💡 불완전한 주석 제거
public record UploadFile( String key, - InputStream inputStream, // 아래 내용들을. + InputStream inputStream, String contentType, Long size ) {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/com/kustacks/kuring/storage/application/port/in/dto/UploadFileCommand.java` around lines 9 - 14, Remove the incomplete inline comment "// 아래 내용들을." from the UploadFile record declaration and either replace it with a brief, meaningful comment (e.g. noting that InputStream must be closed by the caller) or remove the comment entirely; also add a short note in the Javadoc or comment for the UploadFile record that InputStream lifecycle is the caller's responsibility and callers (including StorageCommandService.upload()) must close the stream (e.g. via try-with-resources) to avoid leaks, referencing the UploadFile record and StorageCommandService.upload() so reviewers can verify all usages close the stream.src/main/java/com/kustacks/kuring/storage/adapter/in/event/dto/ClubCreateEvent.java (1)
25-38: InputStream을 record 필드로 저장하는 것은 주의가 필요합니다.
InputStream은 한 번만 읽을 수 있고 내부 상태가 변경되므로, record의 불변성 개념과 맞지 않습니다. PR 목표에서 언급된 대로@Async대신AFTER_COMMIT동기 처리를 선택한 것은 이해하지만, 다음 사항들을 확인해 주세요:
- 이벤트 리스너에서 InputStream이 정확히 한 번만 소비되는지
- 업로드 완료 후 InputStream이 적절히 close 되는지
- 이벤트가 여러 리스너에게 전달되지 않는지
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/com/kustacks/kuring/storage/adapter/in/event/dto/ClubCreateEvent.java` around lines 25 - 38, ClubCreateImage currently stores an InputStream (pathAndName, inputstream, contentType, size) which breaks record immutability and risks double-consumption/ leaking; change ClubCreateImage to capture the file contents as an immutable byte[] (or store the MultipartFile) instead of an InputStream, read and close file.getInputStream() inside the ClubCreateImage constructor, and update toUploadFile() to return UploadFileCommand.UploadFile with a fresh ByteArrayInputStream(new byte[]) so consumers can safely read/close independently; also audit usages of ClubCreateImage and listeners to ensure the stream is consumed exactly once by converting any direct InputStream consumers to use toUploadFile() which provides a fresh stream.src/main/java/com/kustacks/kuring/club/application/port/out/ClubEventPort.java (1)
5-7: 포트 인터페이스에서MultipartFile사용은 Spring 프레임워크와 결합을 만듭니다.헥사고날 아키텍처에서 포트 인터페이스는 프레임워크에 독립적인 것이 이상적입니다.
MultipartFile대신byte[]나InputStream을 사용하면 더 나은 추상화가 가능합니다.다만 PR 설명에서 언급된
MultipartFileInputStream 수명 이슈로 인해 현재 설계를 선택한 것으로 보입니다. 향후 리팩토링 시 고려해 주세요.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/com/kustacks/kuring/club/application/port/out/ClubEventPort.java` around lines 5 - 7, The ClubEventPort interface currently depends on Spring's MultipartFile via the method publishClubCreate in ClubEventPort; replace the MultipartFile parameters with framework-agnostic types (e.g., byte[] or InputStream) to decouple the port from Spring, update all implementations/adapters that implement publishClubCreate to accept and propagate the new types, and ensure any callers (controllers/adapters) convert MultipartFile to the chosen byte[]/InputStream while handling stream lifecycle and buffering concerns so input streams are consumed/closed before leaving the adapter layer.src/main/java/com/kustacks/kuring/club/application/service/ClubCommandService.java (1)
184-233: 이미지 파일의 실제 타입 검증을 추가하는 것이 좋습니다.현재는 비어있는지/확장자 형식만 확인하므로, 비이미지 파일이 이미지로 업로드될 수 있습니다.
개선 예시
+ // 예: 허용 타입 화이트리스트 + // private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of("image/jpeg", "image/png", "image/webp"); + // private static final Set<String> ALLOWED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "webp"); private void validateRequiredIconImage(MultipartFile iconImage) { if (iconImage == null || iconImage.isEmpty()) { throw new InvalidStateException(API_MISSING_PARAM); } + // validateImageFile(iconImage); } + // private void validateOptionalPosterImage(MultipartFile posterImage) { ... } + // private void validateImageFile(MultipartFile file) { ...contentType + extension 검사... }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/com/kustacks/kuring/club/application/service/ClubCommandService.java` around lines 184 - 233, The image upload currently only checks emptiness and filename extension (validateRequiredIconImage, extractExtension, generateFileName) which allows non-image files; add actual image type validation by reading the uploaded MultipartFile content (e.g., using ImageIO or checking magic bytes) to confirm it decodes as a supported image format (jpg/jpeg/png/gif) and determine the canonical extension; if decoding fails or the detected format is not allowed, throw InvalidStateException(API_INVALID_PARAM). Also update generateFileName to use the detected canonical extension instead of the raw filename extension so stored filenames reflect the real image type.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@src/main/java/com/kustacks/kuring/admin/adapter/in/web/AdminCommandApiV2.java`:
- Around line 145-147: Rename the multipart parameter to match the domain naming
to avoid missing poster uploads: change the `@RequestPart`(name = "postImage",
required = false) MultipartFile postImage parameter in AdminCommandApiV2 to use
"posterImage" (both the request part name and the local variable), and update
the call site that passes it into request.toCommand(iconImage, postImage) to
pass the new variable name instead (e.g., request.toCommand(iconImage,
posterImage)); ensure any other references in the same method or class are
updated accordingly.
In
`@src/main/java/com/kustacks/kuring/admin/adapter/out/event/ClubEventAdapter.java`:
- Around line 22-24: The poster image is being constructed with the wrong path:
in ClubEventAdapter when creating the ClubCreateImage for the poster you pass
iconImagePath instead of the poster's path variable; update the
ClubCreateImage(...) call used to build "poster" so it uses the poster image
path (the correct posterImagePath/variable) while keeping the same posterImage
content, then raise the ClubCreateEvent(clubId, icon, poster) unchanged.
- Around line 29-30: In the catch(IOException e) inside ClubEventAdapter (the
block that currently throws new
InvalidStateException(ErrorCode.FILE_IO_EXCEPTION)), preserve the original
exception by passing it as the cause to the InvalidStateException (or calling
initCause) so the IOException stacktrace is retained; update the
InvalidStateException construction to accept the cause (or add an appropriate
constructor) and include e when rethrowing alongside
ErrorCode.FILE_IO_EXCEPTION.
In
`@src/main/java/com/kustacks/kuring/club/application/port/in/ClubCreateAdminUseCase.java`:
- Around line 5-6: The use-case interface ClubCreateAdminUseCase currently
declares void createClub(AdminClubCreateCommand) so the created entity ID from
ClubCommandService.createClub (where savedClub is obtained) cannot be returned
to the controller; change the interface method signature to return either Long
or AdminClubCreateResponse (e.g., Long createClub(... ) or
AdminClubCreateResponse createClub(...)), update the implementing method
ClubCommandService.createClub to return the savedClub.getId() or build and
return an AdminClubCreateResponse, and propagate that return value back to the
controller so it passes the real clubId instead of null.
In
`@src/main/java/com/kustacks/kuring/club/application/port/in/dto/AdminClubCreateCommand.java`:
- Line 11: Change the nullable wrapper type for the isAlways field in
AdminClubCreateCommand from Boolean to the primitive boolean to enforce
non-nullability at the application layer; update the declaration and any
constructor/record component, accessors or usages within AdminClubCreateCommand
(and any places that construct it) to use primitive boolean, relying on the
`@NotNull` validation performed in AdminClubCreateRequest to guarantee a value.
In
`@src/main/java/com/kustacks/kuring/club/application/port/out/ClubCommandPort.java`:
- Around line 8-12: The interface ClubCommandPort is missing the
existsByNameAndDivision method used by ClubPersistenceAdapter; add a declaration
boolean existsByNameAndDivision(String name, ClubDivision division) to
ClubCommandPort (and import ClubDivision) so the persistence adapter can
implement it consistently alongside save(Club) and saveAll(List<ClubSns>);
alternatively, if you prefer separation of command vs query responsibilities,
move that method to a new or existing query port and update
ClubPersistenceAdapter to implement the appropriate port.
In
`@src/main/java/com/kustacks/kuring/club/application/service/ClubCommandService.java`:
- Around line 139-142: 추가 동시성 안전장치가 필요합니다: 엔티티의 (name, division) 컬럼에 DB 유니크 제약을
추가하고, 애플리케이션 레벨의 validateDuplicateClub(AdminClubCreateCommand, ClubDivision) 체크는
유지하되 저장 시 발생하는 제약 위반을 예외로 변환하도록 예외 처리를 추가하세요—즉 Club 엔티티에 복합 유니크 인덱스(또는 DDL 제약)를
선언하고, 실제 저장 호출을 수행하는 코드(예: ClubCommandService의 저장 메서드 또는
clubRepository.save/clubCommandPort.save 호출부)에서 DataIntegrityViolationException
같은 DB 제약 위반 예외를 잡아 InvalidStateException(CLUB_DUPLICATED)로 재던지도록 구현합니다.
In `@src/main/java/com/kustacks/kuring/club/domain/ClubSnsType.java`:
- Around line 17-23: fromUrl currently uses startsWith on the whole URL causing
false positives (e.g. "youtube" in malicious domains); change
ClubSnsType.fromUrl to parse the host via new URL(url).getHost() (handle
MalformedURLException by returning ETC) and compare that host against each
clubSnsType.urls entry using exact match or suffix match with a dot prefix (e.g.
host.equalsIgnoreCase(pattern) || host.endsWith("."+pattern)) instead of
startsWith; update the filtering lambda that currently references
clubSnsType.urls.stream().anyMatch(url::startsWith) to use the parsed host and
the safer equality/suffix checks.
In
`@src/main/java/com/kustacks/kuring/storage/adapter/in/event/dto/ImageDeleteEvent.java`:
- Around line 1-6: ImageDeleteEvent is currently unused; either remove the
record ImageDeleteEvent(String fileName) from the PR if not needed now, or if
you intend it for future work, add a clear comment/Javadoc above the
ImageDeleteEvent record explaining its intended future use and lifecycle and
optionally annotate/suppress unused warnings so static analysis doesn't flag it;
update the commit accordingly and ensure no imports/reference remain broken when
removing or annotating ImageDeleteEvent.
In
`@src/main/java/com/kustacks/kuring/storage/application/port/in/dto/UploadSingleImageCommand.java`:
- Line 6: The incomplete inline comment on the byte[] fileBytes parameter in
UploadSingleImageCommand is unclear; either remove the “// 아래 내용들을.” comment or
replace it with a concise, meaningful description of the parameter’s purpose
(e.g., "file bytes of the uploaded image" or "raw image data") to improve
readability and maintainability; update the JavaDoc or inline comment near the
UploadSingleImageCommand constructor/field to reflect the chosen wording.
- Line 6: Remove the leftover inline comment "// 아래 내용들을." from the record
component declaration in UploadSingleImageCommand and add a compact constructor
that makes a defensive copy of the byte[] fileBytes (e.g., allocate new
byte[fileBytes.length] and System.arraycopy) and assign the copy to
this.fileBytes (or validate null and copy accordingly) so external mutations
cannot affect the record's internal array; keep the rest of the record
unchanged.
---
Outside diff comments:
In `@src/test/java/com/kustacks/kuring/club/domain/ClubCategoryTest.java`:
- Around line 19-25: Rename the misnamed test method fromKorNameName to reflect
what's being tested: e.g., fromName (or fromEnglishName); update the test method
name that calls ClubCategory.fromName(name) and asserts equality with the
expected ClubCategory so the method name matches the tested function
ClubCategory.fromName and the English test data (academic, culture_art, etc.).
- Around line 29-39: The test method name has a typo "fromKorNameNameException"
causing a naming mismatch; rename the test method to a clear, consistent name
(e.g., "fromKorNameException" or "fromNameException") so it matches the other
tests, and keep the test body unchanged — locate the method in ClubCategoryTest
and update the method declaration name that wraps the ThrowingCallable calling
ClubCategory.fromName(name).
In `@src/test/java/com/kustacks/kuring/club/domain/ClubDivisionTest.java`:
- Around line 19-25: The test method name fromKorNameName does not match the
exercised method; update the test to match intent: either rename the test method
fromKorNameName to a descriptive name like fromNameTest (or
fromName_whenValidName_returnsDivision) to reflect that it calls
ClubDivision.fromName(name), or change the test body to call
ClubDivision.fromKorName(name) and adjust test data accordingly; locate the
method in class ClubDivisionTest and update the method name or the invoked
static method (ClubDivision.fromName vs ClubDivision.fromKorName) so name and
behavior are consistent.
- Around line 29-39: Test method name fromKorNameNameException mismatches the
behavior: it calls ClubDivision.fromName(name) but the name implies a "Kor"
variant; rename the test to reflect what's being tested (e.g., fromNameException
or fromNameThrowsNotFoundException) or change the call to
ClubDivision.fromKorName(name) to match the existing name; update the test
method name (fromKorNameNameException) or the invoked method
(ClubDivision.fromName(name)) so the test name and tested symbol align.
---
Nitpick comments:
In
`@src/main/java/com/kustacks/kuring/club/application/port/out/ClubEventPort.java`:
- Around line 5-7: The ClubEventPort interface currently depends on Spring's
MultipartFile via the method publishClubCreate in ClubEventPort; replace the
MultipartFile parameters with framework-agnostic types (e.g., byte[] or
InputStream) to decouple the port from Spring, update all
implementations/adapters that implement publishClubCreate to accept and
propagate the new types, and ensure any callers (controllers/adapters) convert
MultipartFile to the chosen byte[]/InputStream while handling stream lifecycle
and buffering concerns so input streams are consumed/closed before leaving the
adapter layer.
In
`@src/main/java/com/kustacks/kuring/club/application/service/ClubCommandService.java`:
- Around line 184-233: The image upload currently only checks emptiness and
filename extension (validateRequiredIconImage, extractExtension,
generateFileName) which allows non-image files; add actual image type validation
by reading the uploaded MultipartFile content (e.g., using ImageIO or checking
magic bytes) to confirm it decodes as a supported image format
(jpg/jpeg/png/gif) and determine the canonical extension; if decoding fails or
the detected format is not allowed, throw
InvalidStateException(API_INVALID_PARAM). Also update generateFileName to use
the detected canonical extension instead of the raw filename extension so stored
filenames reflect the real image type.
In
`@src/main/java/com/kustacks/kuring/storage/adapter/in/event/dto/ClubCreateEvent.java`:
- Around line 25-38: ClubCreateImage currently stores an InputStream
(pathAndName, inputstream, contentType, size) which breaks record immutability
and risks double-consumption/ leaking; change ClubCreateImage to capture the
file contents as an immutable byte[] (or store the MultipartFile) instead of an
InputStream, read and close file.getInputStream() inside the ClubCreateImage
constructor, and update toUploadFile() to return UploadFileCommand.UploadFile
with a fresh ByteArrayInputStream(new byte[]) so consumers can safely read/close
independently; also audit usages of ClubCreateImage and listeners to ensure the
stream is consumed exactly once by converting any direct InputStream consumers
to use toUploadFile() which provides a fresh stream.
In
`@src/main/java/com/kustacks/kuring/storage/adapter/in/event/StorageEventListener.java`:
- Line 11: 클래스에 붙은 사용되지 않는 `@Slf4j` 어노테이션을 정리하세요: StorageEventListener 클래스에서 로깅을
전혀 사용하지 않으면 `@Slf4j` 주석을 제거하고 불필요한 import를 삭제하거나, 이벤트 수신을 기록하려면
onApplicationEvent(또는 해당 이벤트 핸들러 메서드) 안에 수신 시점과 주요 페이로드(예: 이벤트 타입, id 등)를 기록하는
log.debug/info 호출을 추가해 주세요; 결정한 방식에 따라 `@Slf4j` 유지 시 log를 실제로 사용하거나, 사용하지 않으면
어노테이션과 lombok.slf4j.Slf4j import를 제거하십시오.
In
`@src/main/java/com/kustacks/kuring/storage/application/port/in/dto/UploadFileCommand.java`:
- Around line 9-14: Remove the incomplete inline comment "// 아래 내용들을." from the
UploadFile record declaration and either replace it with a brief, meaningful
comment (e.g. noting that InputStream must be closed by the caller) or remove
the comment entirely; also add a short note in the Javadoc or comment for the
UploadFile record that InputStream lifecycle is the caller's responsibility and
callers (including StorageCommandService.upload()) must close the stream (e.g.
via try-with-resources) to avoid leaks, referencing the UploadFile record and
StorageCommandService.upload() so reviewers can verify all usages close the
stream.
In
`@src/main/java/com/kustacks/kuring/storage/application/StorageCommandService.java`:
- Around line 29-43: The upload method in StorageCommandService currently
swallows failures and only logs them, so callers cannot know which files
succeeded or failed; change upload(UploadFileCommand.UploadFile file) to return
a result object (e.g., UploadResult with fields success:boolean, key:String,
error:String) or at minimum a boolean, populate it from the try/catch around
storagePort.upload, and propagate that result up to the caller so callers can
track successful vs failed files; alternatively (or additionally) emit a metric
or increment a failure counter (e.g., via MeterRegistry) inside the catch blocks
including file.key() and e.getMessage() to expose failures to monitoring.
ℹ️ Review info
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (34)
src/main/java/com/kustacks/kuring/admin/adapter/in/web/AdminCommandApiV2.javasrc/main/java/com/kustacks/kuring/admin/adapter/in/web/dto/AdminClubCreateRequest.javasrc/main/java/com/kustacks/kuring/admin/adapter/in/web/dto/AdminClubCreateResponse.javasrc/main/java/com/kustacks/kuring/admin/adapter/out/event/ClubEventAdapter.javasrc/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubPersistenceAdapter.javasrc/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubRepository.javasrc/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubSnsRepository.javasrc/main/java/com/kustacks/kuring/club/application/port/in/ClubCreateAdminUseCase.javasrc/main/java/com/kustacks/kuring/club/application/port/in/dto/AdminClubCreateCommand.javasrc/main/java/com/kustacks/kuring/club/application/port/out/ClubCommandPort.javasrc/main/java/com/kustacks/kuring/club/application/port/out/ClubEventPort.javasrc/main/java/com/kustacks/kuring/club/application/port/out/ClubQueryPort.javasrc/main/java/com/kustacks/kuring/club/application/service/ClubCommandService.javasrc/main/java/com/kustacks/kuring/club/domain/Club.javasrc/main/java/com/kustacks/kuring/club/domain/ClubCategory.javasrc/main/java/com/kustacks/kuring/club/domain/ClubDivision.javasrc/main/java/com/kustacks/kuring/club/domain/ClubSns.javasrc/main/java/com/kustacks/kuring/club/domain/ClubSnsType.javasrc/main/java/com/kustacks/kuring/common/dto/ResponseCodeAndMessages.javasrc/main/java/com/kustacks/kuring/common/exception/code/ErrorCode.javasrc/main/java/com/kustacks/kuring/storage/adapter/in/event/StorageEventListener.javasrc/main/java/com/kustacks/kuring/storage/adapter/in/event/dto/ClubCreateEvent.javasrc/main/java/com/kustacks/kuring/storage/adapter/in/event/dto/ImageDeleteEvent.javasrc/main/java/com/kustacks/kuring/storage/adapter/out/MockStorageAdapter.javasrc/main/java/com/kustacks/kuring/storage/adapter/out/S3CompatibleStorageAdapter.javasrc/main/java/com/kustacks/kuring/storage/application/StorageCommandService.javasrc/main/java/com/kustacks/kuring/storage/application/port/in/StorageUploadUseCase.javasrc/main/java/com/kustacks/kuring/storage/application/port/in/dto/UploadFileCommand.javasrc/main/java/com/kustacks/kuring/storage/application/port/in/dto/UploadSingleImageCommand.javasrc/main/resources/db/migration/V260227__Add_icon_image_path_to_club.sqlsrc/test/java/com/kustacks/kuring/acceptance/AdminAcceptanceTest.javasrc/test/java/com/kustacks/kuring/acceptance/AdminStep.javasrc/test/java/com/kustacks/kuring/club/domain/ClubCategoryTest.javasrc/test/java/com/kustacks/kuring/club/domain/ClubDivisionTest.java
src/main/java/com/kustacks/kuring/admin/adapter/in/web/AdminCommandApiV2.java
Outdated
Show resolved
Hide resolved
src/main/java/com/kustacks/kuring/admin/adapter/out/event/ClubEventAdapter.java
Show resolved
Hide resolved
src/main/java/com/kustacks/kuring/admin/adapter/out/event/ClubEventAdapter.java
Show resolved
Hide resolved
src/main/java/com/kustacks/kuring/club/application/port/in/ClubCreateAdminUseCase.java
Show resolved
Hide resolved
src/main/java/com/kustacks/kuring/club/application/port/in/dto/AdminClubCreateCommand.java
Outdated
Show resolved
Hide resolved
src/main/java/com/kustacks/kuring/club/application/port/out/ClubCommandPort.java
Show resolved
Hide resolved
src/main/java/com/kustacks/kuring/club/application/service/ClubCommandService.java
Show resolved
Hide resolved
src/main/java/com/kustacks/kuring/storage/adapter/in/event/dto/ImageDeleteEvent.java
Outdated
Show resolved
Hide resolved
src/main/java/com/kustacks/kuring/storage/application/port/in/dto/UploadSingleImageCommand.java
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
♻️ Duplicate comments (1)
src/main/java/com/kustacks/kuring/admin/adapter/out/event/ClubEventAdapter.java (1)
29-30:⚠️ Potential issue | 🟡 MinorIOException 원인(
e)을 함께 전달해 주세요.현재는 예외 원인 스택이 유실됩니다.
InvalidStateException에 cause를 넘겨서 장애 추적 가능성을 유지하는 게 좋습니다.제안 수정
- } catch (IOException e) { - throw new InvalidStateException(ErrorCode.FILE_IO_EXCEPTION); + } catch (IOException e) { + throw new InvalidStateException(ErrorCode.FILE_IO_EXCEPTION, e); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/com/kustacks/kuring/admin/adapter/out/event/ClubEventAdapter.java` around lines 29 - 30, The IOException caught in ClubEventAdapter should be propagated as the cause when throwing InvalidStateException so the original stacktrace isn't lost; update the catch block that currently does "throw new InvalidStateException(ErrorCode.FILE_IO_EXCEPTION);" to pass the caught exception (e) as the cause via InvalidStateException's constructor or initCause (e.g., new InvalidStateException(ErrorCode.FILE_IO_EXCEPTION, e)), ensuring InvalidStateException's constructor supports a Throwable cause or add one if needed.
🧹 Nitpick comments (1)
src/main/java/com/kustacks/kuring/admin/adapter/out/event/ClubEventAdapter.java (1)
22-27: 이벤트 발행 분기 중복을 줄이면 더 읽기 쉬워집니다.
Events.raise(...)호출이 분기마다 반복되어 있어, 포스터만 먼저 계산하고 이벤트는 한 번만 발행하면 가독성이 좋아집니다.리팩터링 예시
- if (posterImage != null) { - ClubCreateImage poster = new ClubCreateImage(posterImagePath, posterImage); - Events.raise(new ClubCreateEvent(clubId, icon, poster)); - } else { - Events.raise(new ClubCreateEvent(clubId, icon, null)); - } + ClubCreateImage poster = null; + if (posterImage != null) { + poster = new ClubCreateImage(posterImagePath, posterImage); + } + Events.raise(new ClubCreateEvent(clubId, icon, poster));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/com/kustacks/kuring/admin/adapter/out/event/ClubEventAdapter.java` around lines 22 - 27, posterImage 분기에 따라 Events.raise(...) 호출이 중복되어 있으니 먼저 posterImage를 기반으로 ClubCreateImage poster 변수를 계산(포스터가 없으면 null)하고 그 후 한 번만 Events.raise(new ClubCreateEvent(clubId, icon, poster))를 호출하도록 변경하세요; 관련 식별자: posterImage, posterImagePath, ClubCreateImage, poster, Events.raise, ClubCreateEvent, clubId, icon.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In
`@src/main/java/com/kustacks/kuring/admin/adapter/out/event/ClubEventAdapter.java`:
- Around line 29-30: The IOException caught in ClubEventAdapter should be
propagated as the cause when throwing InvalidStateException so the original
stacktrace isn't lost; update the catch block that currently does "throw new
InvalidStateException(ErrorCode.FILE_IO_EXCEPTION);" to pass the caught
exception (e) as the cause via InvalidStateException's constructor or initCause
(e.g., new InvalidStateException(ErrorCode.FILE_IO_EXCEPTION, e)), ensuring
InvalidStateException's constructor supports a Throwable cause or add one if
needed.
---
Nitpick comments:
In
`@src/main/java/com/kustacks/kuring/admin/adapter/out/event/ClubEventAdapter.java`:
- Around line 22-27: posterImage 분기에 따라 Events.raise(...) 호출이 중복되어 있으니 먼저
posterImage를 기반으로 ClubCreateImage poster 변수를 계산(포스터가 없으면 null)하고 그 후 한 번만
Events.raise(new ClubCreateEvent(clubId, icon, poster))를 호출하도록 변경하세요; 관련 식별자:
posterImage, posterImagePath, ClubCreateImage, poster, Events.raise,
ClubCreateEvent, clubId, icon.
ℹ️ Review info
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
src/main/java/com/kustacks/kuring/admin/adapter/in/web/AdminCommandApiV2.javasrc/main/java/com/kustacks/kuring/admin/adapter/out/event/ClubEventAdapter.java
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
src/test/java/com/kustacks/kuring/club/domain/ClubSnsTypeTest.java (1)
37-49:InvalidStateException발생 경로에 대한 테스트 누락현재 테스트 케이스들(빈 문자열, 공백, "not-a-url")은
URI.create()에서 예외를 발생시키지 않고 null 호스트로 처리되어 ETC를 반환합니다. 프로덕션 코드의InvalidStateException발생 경로(Line 44-45)를 검증하는 테스트가 없습니다.✅ 예외 발생 케이스 테스트 추가 제안
+ `@DisplayName`("유효하지 않은 URI 형식은 예외를 발생시킨다") + `@Test` + void fromUrl_withMalformedUri_throwsException() { + assertThatThrownBy(() -> ClubSnsType.fromUrl("https://example.com/path with spaces")) + .isInstanceOf(InvalidStateException.class); + }필요한 import 추가:
import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.kustacks.kuring.common.exception.InvalidStateException;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/test/java/com/kustacks/kuring/club/domain/ClubSnsTypeTest.java` around lines 37 - 49, The test suite is missing coverage for the code path that throws InvalidStateException in ClubSnsType.fromUrl; add a unit test that invokes ClubSnsType.fromUrl with a URL that causes URI.create() to throw and assert that InvalidStateException is thrown (e.g., a clearly malformed URI), using assertThatThrownBy to verify the exception type; update imports to include static org.assertj.core.api.Assertions.assertThatThrownBy and com.kustacks.kuring.common.exception.InvalidStateException and add the new test method (or parameterized case) alongside fromUrl_withInvalidOrUnsupported to validate the exception path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/main/java/com/kustacks/kuring/club/domain/ClubSnsType.java`:
- Around line 40-47: The extractHost(String url) method can NPE on url.trim()
when callers pass null (e.g., fromUrl(null)); add a null/blank guard at the
start of extractHost to validate the incoming url (check for null or empty after
trimming) and throw the same InvalidStateException(ErrorCode.API_INVALID_PARAM)
for invalid input; then proceed to URI.create(url.trim()) and return
uri.getHost() as before.
---
Nitpick comments:
In `@src/test/java/com/kustacks/kuring/club/domain/ClubSnsTypeTest.java`:
- Around line 37-49: The test suite is missing coverage for the code path that
throws InvalidStateException in ClubSnsType.fromUrl; add a unit test that
invokes ClubSnsType.fromUrl with a URL that causes URI.create() to throw and
assert that InvalidStateException is thrown (e.g., a clearly malformed URI),
using assertThatThrownBy to verify the exception type; update imports to include
static org.assertj.core.api.Assertions.assertThatThrownBy and
com.kustacks.kuring.common.exception.InvalidStateException and add the new test
method (or parameterized case) alongside fromUrl_withInvalidOrUnsupported to
validate the exception path.
ℹ️ Review info
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
src/main/java/com/kustacks/kuring/club/application/port/in/dto/AdminClubCreateCommand.javasrc/main/java/com/kustacks/kuring/club/domain/ClubSnsType.javasrc/test/java/com/kustacks/kuring/club/domain/ClubSnsTypeTest.java
🚧 Files skipped from review as they are similar to previous changes (1)
- src/main/java/com/kustacks/kuring/club/application/port/in/dto/AdminClubCreateCommand.java
There was a problem hiding this comment.
♻️ Duplicate comments (1)
src/main/java/com/kustacks/kuring/club/domain/ClubSnsType.java (1)
35-46:⚠️ Potential issue | 🟠 Major유효하지 않은 URL이
ETC로 우회될 수 있습니다.Line 42의
URI.create(url.trim())는 빈 문자열/상대 URI에서 예외 없이host == null을 반환할 수 있고, Line 37에서 그대로ETC로 매핑됩니다. 또한 host 소문자 정규화가 없어 대문자 도메인 입력 시 오분류될 수 있습니다.🔧 제안 수정안
import java.net.URI; +import java.util.Locale; import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Set; @@ private static String extractHost(String url) { try { - URI uri = URI.create(url.trim()); - return uri.getHost(); - } catch (IllegalArgumentException | NullPointerException e) { - throw new InvalidStateException(ErrorCode.API_INVALID_PARAM); + if (url == null || url.isBlank()) { + throw new InvalidStateException(ErrorCode.API_INVALID_PARAM); + } + URI uri = URI.create(url.trim()); + String host = uri.getHost(); + if (host == null || host.isBlank()) { + throw new InvalidStateException(ErrorCode.API_INVALID_PARAM); + } + return host.toLowerCase(Locale.ROOT); + } catch (IllegalArgumentException e) { + throw new InvalidStateException(ErrorCode.API_INVALID_PARAM, e); } }In Java (JDK), for java.net.URI: 1) Does URI.create("instagram.com/path").getHost() return null without throwing? 2) Is URI#getHost case-normalized, or should callers manually lowercase for case-insensitive host matching?🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/com/kustacks/kuring/club/domain/ClubSnsType.java` around lines 35 - 46, The current ClubSnsType.fromUrl / extractHost flow can silently treat invalid or relative URLs (and uppercase hosts) as ETC because URI.create(url).getHost() can return null; update extractHost(String url) to: trim input, attempt to ensure a scheme (e.g., if url does not start with a scheme, prepend "https://") before creating the URI, check uri.getHost() for null and if null throw InvalidStateException(ErrorCode.API_INVALID_PARAM) instead of returning null, and normalize the returned host to lowercase before returning so HOST_TO_TYPE lookup is case-insensitive; keep the caller (fromUrl) using HOST_TO_TYPE.getOrDefault(host, ETC) after these validations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@src/main/java/com/kustacks/kuring/club/domain/ClubSnsType.java`:
- Around line 35-46: The current ClubSnsType.fromUrl / extractHost flow can
silently treat invalid or relative URLs (and uppercase hosts) as ETC because
URI.create(url).getHost() can return null; update extractHost(String url) to:
trim input, attempt to ensure a scheme (e.g., if url does not start with a
scheme, prepend "https://") before creating the URI, check uri.getHost() for
null and if null throw InvalidStateException(ErrorCode.API_INVALID_PARAM)
instead of returning null, and normalize the returned host to lowercase before
returning so HOST_TO_TYPE lookup is case-insensitive; keep the caller (fromUrl)
using HOST_TO_TYPE.getOrDefault(host, ETC) after these validations.
|
서비스 로직 수정사항이 확인되서 수정하고 다시 올리겠습니다. |
#️⃣ 이슈
📌 요약
POST /api/v2/admin/clubs)를 추가했습니다.🛠️ 상세
마이그레이션
club.icon_image_path컬럼 추가API/DTO
AdminCommandApiV2동아리 생성 엔드포인트 추가AdminClubCreateRequest,AdminClubCreateResponse추가도메인/서비스
Club생성 필드 확장(icon/poster, 모집 정보 등)카테고리/소속 한글 매핑 지원
중복 체크(
name + division) 및 입력 검증(날짜/URL/필수 이미지) 반영저장/이벤트
ClubCommandPort,ClubEventPort,ClubCreateAdminUseCase등 계약 추가ClubPersistenceAdapter,ClubRepository,ClubSnsRepository구현ClubCreateEvent,StorageEventListener,StorageCommandService로 이미지 업로드 처리테스트
AdminAcceptanceTest동아리 업로드 케이스 추가AdminStep에 요청 직렬화/업로드 헬퍼 추가ClubCategoryTest,ClubDivisionTest정리이벤트 기반 업로드 동작 흐름
POST /api/v2/admin/clubs로 multipart 요청을 보냅니다.club,club_sns를 트랜잭션 내에서 DB에 저장합니다.ClubCreateEvent를 발행합니다.@TransactionalEventListener(AFTER_COMMIT)가 이벤트를 수신합니다.@Async를 도입했으나,MultipartFile InputStream의 수명 이슈가 있어, AFTER_COMMIT 동기 처리로 구현했습니다.💬 기타
lat/lon/building/room같은 위치 정보는 향후 별도 테이블(예: building/location)로 분리 관리가 필요합니다.Summary by CodeRabbit
새로운 기능
개선 사항
테스트